########################################################################
#  Searx-Qt - Lightweight desktop application for Searx.
#  Copyright (C) 2020-2022  CYBERDEViL
#
#  This file is part of Searx-Qt.
#
#  Searx-Qt is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  Searx-Qt is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
########################################################################


from PyQt5.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QLineEdit,
    QTextBrowser,
    QCheckBox,
    QLabel,
    QComboBox,
    QFrame,
    QMenu,
    QWidgetAction,
    QShortcut,
    QSpacerItem,
    QSizePolicy,
    QDialog,
    QListWidget,
    QTableView,
    QSplitter,
    QAbstractItemView,
    QHeaderView,
    QFormLayout,
    QMessageBox
)

from PyQt5.QtCore import pyqtSignal, Qt, QVariant, QByteArray, QEvent

from PyQt5.QtGui import (
    QDesktopServices,
    QStandardItem,
    QStandardItemModel,
    QTextDocument
)

from searxqt.core.customAnchorCmd import AnchorCMD
from searxqt.core.htmlGen import ResultsHtml, FailedResponsesHtml
from searxqt.core.guard import ConsequenceType
from searxqt.core.http import ErrorType

from searxqt.models.search import (
    SearchStatus,
    UserCategoriesModel,
    CategoriesModel
)
from searxqt.models.instances import EnginesTableModel
from searxqt.widgets.buttons import Button, CheckboxOptionsButton

from searxqt.translations import _

from searxqt.themes import Themes
from searxqt.core import log


class SearchNavigation(QWidget):
    requestPage = pyqtSignal(int)  # pageno

    def __init__(self, parent=None):
        QWidget.__init__(self, parent=parent)
        layout = QHBoxLayout(self)

        self.prevPageButton = Button("◄", self)
        self.pageNoLabel = QLabel("1", self)
        self.nextPageButton = Button("►", self)

        layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
        layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
        layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)

        self.prevPageButton.setEnabled(False)

        self.nextPageButton.clicked.connect(self._nextPage)
        self.prevPageButton.clicked.connect(self._prevPage)

        self.reset()

    def _updateLabel(self):
        self.pageNoLabel.setText(str(self._pageno))

    def _nextPage(self):
        self._pageno += 1
        if self._pageno > 1 and not self.prevPageButton.isEnabled():
            self.prevPageButton.setEnabled(True)
        self._updateLabel()
        self.requestPage.emit(self._pageno)

    def _prevPage(self):
        self._pageno -= 1
        if self._pageno == 1:
            self.prevPageButton.setEnabled(False)
        self.setNextEnabled(True)
        self._updateLabel()
        self.requestPage.emit(self._pageno)

    def reset(self):
        self._pageno = 1
        self.prevPageButton.setEnabled(False)
        self._updateLabel()

    def setNextEnabled(self, state):
        self.nextPageButton.setEnabled(state)


class SearchEngines(CheckboxOptionsButton):
    def __init__(self, searchModel, instancesModel, parent=None):
        """
        @param searchModel: needed for getting and setting current
                            enabled/disabled engines.
        @type searchModel: SearchModel

        @param instancesModel: needed for listing current available
                               engines and update it's current filter
                               to filter out instances without atleast
                               one of the required engine(s).
        @type instancesModel: InstanceModelFilter
        """
        self._instancesModel = instancesModel
        self._searchModel = searchModel
        CheckboxOptionsButton.__init__(
            self,
            labelName=_("Engines"),
            parent=parent
        )

        instancesModel.parentModel().changed.connect(self.reGenerate)

    def updateFilter(self):
        """ Filter out instances that don't support atleast one of the
        enabled engines.
        """
        self._instancesModel.updateKwargs(
            {'engines': self.getCheckedOptionNames()}
        )

    """ Below are re-implementations.
    """
    def getCheckedOptionNames(self):
        """ Should return a list with checked option names. This will
        be used to generate the label.

        @return: should return a list with strings.
        @rtype: list
        """
        return self._searchModel.engines

    def getOptions(self):
        """ Should return a list with options tuple(key, name, state)

        This will be used to generate the options.
        """
        list_ = []
        tmp = []
        for url, instance in self._instancesModel.items():
            for engine in instance.engines:
                if engine.name not in tmp:
                    state = bool(engine.name in self._searchModel.engines)
                    list_.append((engine.name, engine.name, state))
                    tmp.append(engine.name)

        return sorted(list_)

    def optionToggled(self, key, state):
        if state:
            self._searchModel.engines.append(key)
        else:
            self._searchModel.engines.remove(key)

        self.updateFilter()


class CategoryEditor(QDialog):
    def __init__(self, enginesModel, categoriesModel,
                 userCategoriesModel, parent=None):
        QDialog.__init__(self, parent=parent)

        self._categoriesModel = categoriesModel
        self._userCategoriesModel = userCategoriesModel

        self.setWindowTitle(_("Category manager"))

        layout = QHBoxLayout(self)

        # Splitter to horizontal split the categories widget and the engines
        # widget so their width becomes adjustable.
        self.splitter = QSplitter(self)
        self.splitter.setOrientation(Qt.Horizontal)
        layout.addWidget(self.splitter)

        # Categories
        catWidget = QWidget(self.splitter)
        catLayout = QVBoxLayout(catWidget)
        label = QLabel(f"<h2>{_('Categories')}</h2>", self)

        # Categories toolbuttons
        catToolLayout = QHBoxLayout()

        catAddButton = Button("+", self)
        self._catDelButton = Button("-", self)

        catToolLayout.addWidget(catAddButton, 0, Qt.AlignLeft)
        catToolLayout.addWidget(self._catDelButton, 1, Qt.AlignLeft)

        self._categoryListWidget = QListWidget(self)

        catLayout.addWidget(label)
        catLayout.addLayout(catToolLayout)
        catLayout.addWidget(self._categoryListWidget)

        # Engines
        engWidget = QWidget(self.splitter)
        engLayout = QVBoxLayout(engWidget)
        label = QLabel(f"<h2>{_('Engines')}</h2>", self)

        # Engines filter
        filterLayout = QHBoxLayout()

        self._enginesCategoryFilterBox = QComboBox(self)
        self._enginesCategoryFilterBox.addItem(_("All"))

        for key, cat in categoriesModel.items():
            self._enginesCategoryFilterBox.addItem(cat.name)

        filterLayout.addWidget(
            QLabel(_("Category") + ":", self), 1, Qt.AlignRight
        )
        filterLayout.addWidget(
            self._enginesCategoryFilterBox, 0, Qt.AlignRight
        )

        # Engines table
        self._enginesTableView = QTableView(self)
        self._enginesTableView.setAlternatingRowColors(True)
        self._enginesTableView.setSelectionBehavior(
            QAbstractItemView.SelectRows
        )
        self._enginesTableView.setEditTriggers(
            QAbstractItemView.NoEditTriggers
        )
        self._enginesTableView.setSortingEnabled(True)
        self._enginesTableView.setHorizontalScrollMode(
            QAbstractItemView.ScrollPerPixel
        )

        header = self._enginesTableView.horizontalHeader()
        header.setSectionResizeMode(QHeaderView.ResizeToContents)
        header.setSectionsMovable(True)

        self._enginesTableModel = EnginesTableModel(enginesModel, self)

        self._enginesTableView.setModel(self._enginesTableModel)

        engLayout.addWidget(label)
        engLayout.addLayout(filterLayout)
        engLayout.addWidget(self._enginesTableView)

        # Connections
        catAddButton.clicked.connect(self.__addCategoryClicked)
        self._catDelButton.clicked.connect(self.__delCategoryClicked)
        self._categoryListWidget.currentRowChanged.connect(
            self.__currentUserCategoryChanged
        )
        self._enginesCategoryFilterBox.currentIndexChanged.connect(
            self.__enginesCategoryFilterChanged
        )

        self._enginesTableView.setEnabled(False)
        self._catDelButton.setEnabled(False)
        self.__addUserCategories()
        self.__selectFirst()

    def __currentUserCategoryChanged(self, index):
        if index < 0:
            self._enginesTableView.setEnabled(False)
            self._catDelButton.setEnabled(False)
        else:
            self._enginesTableView.setEnabled(True)
            self._catDelButton.setEnabled(True)

        if self._userCategoriesModel:
            key = list(self._userCategoriesModel.keys())[index]
            self._enginesTableModel.setUserModel(
                self._userCategoriesModel[key]
            )

    def __addUserCategories(self):
        for catKey, cat in self._userCategoriesModel.items():
            self._categoryListWidget.addItem(cat.name)

    def __selectFirst(self):
        if self._categoryListWidget.count():
            self._categoryListWidget.setCurrentRow(0)

    def __selectLast(self):
        self._categoryListWidget.setCurrentRow(
            self._categoryListWidget.count() - 1
        )

    def __reloadUserCategories(self):
        self._categoryListWidget.currentRowChanged.disconnect(
            self.__currentUserCategoryChanged
        )
        self._enginesTableModel.setUserModel(None)
        self._categoryListWidget.clear()
        self.__addUserCategories()
        self._categoryListWidget.currentRowChanged.connect(
            self.__currentUserCategoryChanged
        )

    def __addCategoryClicked(self, state):
        dialog = AddUserCategoryDialog(
            self._userCategoriesModel.keys()
        )
        if dialog.exec():
            self._userCategoriesModel.addCategory(
                dialog.name.lower(),
                dialog.name
            )
            self.__reloadUserCategories()
            self.__selectLast()

    def __delCategoryClicked(self, state):
        index = self._categoryListWidget.currentRow()
        key = list(self._userCategoriesModel.keys())[index]

        confirmDialog = QMessageBox()
        confirmDialog.setWindowTitle(
            _("Delete category")
        )
        categoryName = self._userCategoriesModel[key].name
        confirmDialog.setText(_("Are you sure you want to delete the " \
                                f"category `{categoryName}`?"))
        confirmDialog.setStandardButtons(
            QMessageBox.Yes | QMessageBox.No
        )
        confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
        confirmDialog.button(QMessageBox.No).setText(_("No"))

        if confirmDialog.exec() != QMessageBox.Yes:
            return

        self._userCategoriesModel.removeCategory(key)
        self.__reloadUserCategories()
        self.__selectLast()
        self.__currentUserCategoryChanged(
            self._categoryListWidget.count() - 1
        )

    def __enginesCategoryFilterChanged(self, index):
        if not index:  # All
            self._enginesTableModel.setCatFilter()
        else:
            key = list(self._categoriesModel.keys())[index-1]
            self._enginesTableModel.setCatFilter(key)


class AddUserCategoryDialog(QDialog):
    def __init__(self, existingNames=[], text="", parent=None):
        QDialog.__init__(self, parent=parent)

        self._existingNames = existingNames

        layout = QFormLayout(self)

        label = QLabel(_("Name") + ":")
        self._nameEdit = QLineEdit(self)
        if text:
            self._nameEdit.setText(text)
            self._nameEdit.setPlaceholderText(text)
        else:
            self._nameEdit.setPlaceholderText(_("My category"))

        self._cancelButton = Button(_("Cancel"), self)
        self._addButton = Button(_("Add"), self)
        self._addButton.setEnabled(False)

        # Add stuff to layout
        layout.addRow(label, self._nameEdit)
        layout.addRow(self._cancelButton, self._addButton)

        # Connections
        self._nameEdit.textChanged.connect(self.__inputChanged)
        self._addButton.clicked.connect(self.accept)
        self._cancelButton.clicked.connect(self.reject)

    def __inputChanged(self, text):
        if self.isValid():
            self._addButton.setEnabled(True)
        else:
            self._addButton.setEnabled(False)

    def isValid(self):
        name = self._nameEdit.text().lower()
        if not name:
            return False
        for existingName in self._existingNames:
            if name == existingName.lower():
                return False
        return True

    @property
    def name(self):
        return self._nameEdit.text()


class SearchCategories(CheckboxOptionsButton):
    def __init__(self, categoriesModel, instanceCategoriesModel, enginesModel,
                 userCategoriesModel, parent=None):
        """
        @param categoriesModel: Predefined categories (only avaiable when at
                                least one instance has the category).
        @type categoriesModel: searxqt.models.search.CategoriesModel

        @param instanceCategoriesModel: Some instances define custom search
                                        categories.
        @type instanceCategoriesModel: searxqt.models.search.CategoriesModel

        @param enginesModel:
        @type enginesModel: searxqt.models.instances.EnginesModel

        @param userCategoriesModel:
        @type userCategoriesModel: searxqt.models.search.UserCategoriesModel

        @param parent:
        @type parent: QObject or None
        """
        self._categoriesModel = categoriesModel
        self._instanceCategoriesModel = instanceCategoriesModel
        self._enginesModel = enginesModel
        self._userCategoriesModel = userCategoriesModel

        CheckboxOptionsButton.__init__(
            self,
            labelName=_("Categories"),
            parent=parent
        )

        self._categoriesModel.dataChanged.connect(self.__categoryDataChanged)

    def __categoryDataChanged(self):
        # This happends after CategoriesModel.setData
        self.reGenerate()

    def __openUserCategoryEditor(self):
        window = CategoryEditor(
            self._enginesModel,
            self._categoriesModel,
            self._userCategoriesModel, self)
        window.exec()

    def __userCategoryToggled(self, key, state):
        if state:
            self._userCategoriesModel[key].check()
        else:
            self._userCategoriesModel[key].uncheck()

        self.reGenerate()

    def __instanceCategoryToggled(self, key, state):
        if state:
            self._instanceCategoriesModel[key].check()
        else:
            self._instanceCategoriesModel[key].uncheck()

        self.reGenerate()

    """ Below are re-implementations.
    """
    def addCustomWidgetsTop(self, menu):
        # User specified categories
        menu.addSection(_("Custom"))

        action = QWidgetAction(menu)
        manageCustomButton = Button(_("Manage"), menu)
        action.setDefaultWidget(manageCustomButton)
        menu.addAction(action)

        for catKey, cat in self._userCategoriesModel.items():
            action = QWidgetAction(menu)
            widget = QCheckBox(cat.name, menu)
            widget.setTristate(False)
            widget.setChecked(
                cat.isChecked()
            )
            action.setDefaultWidget(widget)

            widget.stateChanged.connect(
                lambda state, key=catKey:
                    self.__userCategoryToggled(key, state)
            )

            menu.addAction(action)

        # Custom instance specified categories
        menu.addSection(_("Instances"))

        for catKey, cat in self._instanceCategoriesModel.items():
            action = QWidgetAction(menu)
            widget = QCheckBox(cat.name, menu)
            widget.setTristate(False)
            widget.setChecked(cat.isChecked())
            action.setDefaultWidget(widget)

            widget.stateChanged.connect(
                lambda state, key=catKey:
                    self.__instanceCategoryToggled(key, state)
            )

            menu.addAction(action)

        # Predefined Searx categories
        menu.addSection(_("Default"))

        manageCustomButton.clicked.connect(self.__openUserCategoryEditor)

    def hasEnabledCheckedKeys(self):
        """ Same as CheckboxOptionsButton.hasEnabledCheckedKeys(self) but with
        User Categories. Categories don't get enabled/disabled so we can skip
        that check.

        @rtype: bool
        """
        if self._userCategoriesModel.checkedCategories():
            return True
        elif self._instanceCategoriesModel.checkedCategories():
            return True

        return CheckboxOptionsButton.hasEnabledCheckedKeys(self)

    def uncheckAllEnabledKeys(self):
        """ Unchecks all checked keys that are enabled.
        """
        for catKey in self._userCategoriesModel.checkedCategories():
            self._userCategoriesModel[catKey].uncheck()

        for catKey in self._instanceCategoriesModel.checkedCategories():
            self._instanceCategoriesModel[catKey].uncheck()

        CheckboxOptionsButton.uncheckAllEnabledKeys(self)

    def getCheckedOptionNames(self):
        """ Should return a list with checked option names. This will
        be used to generate the label.

        @return: should return a list with strings.
        @rtype: list
        """
        return(
            [
                self._categoriesModel[catKey].name
                for catKey in self._categoriesModel.checkedCategories()
            ] +
            [
                self._instanceCategoriesModel[catKey].name
                for catKey in self._instanceCategoriesModel.checkedCategories()
            ] +
            [
                self._userCategoriesModel[catKey].name
                for catKey in self._userCategoriesModel.checkedCategories()
            ]
        )

    def getOptions(self):
        """ Should return a list with options tuple(key, name, state)

        This will be used to generate the options.
        """
        list_ = []
        for catKey in self._categoriesModel:
            list_.append(
                (
                    catKey,
                    self._categoriesModel[catKey].name,
                    self._categoriesModel[catKey].isChecked()
                )
            )
        return list_

    def optionToggled(self, key, state):
        if state:
            self._categoriesModel[key].check()
        else:
            self._categoriesModel[key].uncheck()


class SearchPeriod(QComboBox):
    def __init__(self, model, parent=None):
        QComboBox.__init__(self, parent=parent)
        self._model = model

        self.setMinimumContentsLength(2)

        for period in model.Periods:
            self.addItem(model.Periods[period], QVariant(period))

        self.currentIndexChanged.connect(self.__indexChanged)

    def __indexChanged(self, index):
        self._model.timeRange = self.currentData()


class SearchLanguage(QComboBox):
    def __init__(self, model, parent=None):
        QComboBox.__init__(self, parent=parent)
        self._model = model
        self._favorites = []

        self.setMinimumContentsLength(2)

        self.__itemModel = QStandardItemModel(self)
        self.setModel(self.__itemModel)

        self.currentIndexChanged.connect(self.__indexChanged)
        self.__itemModel.itemChanged.connect(self.__favCheckChanged)

    def __indexChanged(self, index):
        self._model.lang = self.currentData()

    def __favCheckChanged(self, item):
        lang = item.data(Qt.UserRole)
        index = item.row()
        newIndex = 0
        if item.checkState():
            # Language added to favorites.
            self._favorites.append(lang)
        else:
            # Remove language from favorites.
            langList = list(self._model.Languages.keys())
            newIndex = langList.index(lang)
            self._favorites.remove(lang)

            # Index offset
            for favLang in self._favorites:
                if langList.index(favLang) < newIndex:
                    newIndex -= 1
            newIndex += len(self._favorites)

        self.__itemModel.takeRow(index)
        self.__itemModel.insertRow(newIndex, item)

    def populate(self):
        self.__itemModel.clear()
        for lang in self._model.Languages:
            newItem = QStandardItem(self._model.Languages[lang])
            newItem.setCheckable(True)
            newItem.setFlags(
                Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
            )
            newItem.setData(QVariant(lang), Qt.UserRole)

            if lang in self._favorites:
                newItem.setData(Qt.Checked, Qt.CheckStateRole)
                self.__itemModel.insertRow(0, newItem)
                continue

            newItem.setData(Qt.Unchecked, Qt.CheckStateRole)
            self.__itemModel.appendRow(newItem)

    def loadSettings(self, data):
        for lang in data.get('favs', []):
            self._favorites.append(lang)

        self.populate()

        # Find and set the index that matches lang
        self.setCurrentIndex(
            self.findData(QVariant(data.get('lang', '')), Qt.UserRole)
        )

    def saveSettings(self):
        return {
            'lang': str(self.currentData()),
            'favs': self._favorites
        }


class SearchOptionsContainer(QFrame):
    """ Custom QFrame to be able to show or hide certain widgets.
    """
    def __init__(self, searchModel, instancesModel, enginesModel, parent=None):
        """
        @param searchModel:
        @type searchModel: searxqt.models.search.SearchModel

        @param instancesModel:
        @type instancesModel: searxqt.models.instances.InstanceModelFilter

        @param enginesModel:
        @type enginesModel: searxqt.models.instances.EnginesModel

        @param parent:
        @type parent: QObject or None
        """
        QFrame.__init__(self, parent=parent)

        self._enginesModel = enginesModel
        self._searchModel = searchModel

        self._categoriesModel = CategoriesModel()
        self._instanceCategoriesModel = CategoriesModel()
        self._userCategoriesModel = UserCategoriesModel()

        # Backup user checked engines.
        self.__userCheckedBackup = []

        # Keep track of disabled engines (these engines are disabled
        # because they are part of one or more checked categories).
        #
        # @key: engine name (str)
        # @value : list with category keys (str)
        self.__disabledByCat = {}

        layout = QHBoxLayout(self)

        self._widgets = {
            'categories': SearchCategories(
                self._categoriesModel,
                self._instanceCategoriesModel,
                enginesModel,
                self._userCategoriesModel,
                self
            ),
            'engines': SearchEngines(searchModel, instancesModel, self),
            'period': SearchPeriod(searchModel, self),
            'lang': SearchLanguage(searchModel, self)
        }

        for widget in self._widgets.values():
            layout.addWidget(widget, 0, Qt.AlignTop)

        # Keep widgets left aligned.
        spacer = QSpacerItem(
            40, 20, QSizePolicy.MinimumExpanding, QSizePolicy.Minimum
        )
        layout.addItem(spacer)

        # Connections
        self._categoriesModel.stateChanged.connect(
            self.__categoriesStateChanged
        )
        self._instanceCategoriesModel.stateChanged.connect(
            self.__categoriesStateChanged
        )
        self._userCategoriesModel.stateChanged.connect(
            self.__userCategoriesStateChanged
        )
        self._userCategoriesModel.changed.connect(self.__userCategoriesChanged)
        self._userCategoriesModel.removed.connect(self.__userCategoryRemoved)
        self._enginesModel.changed.connect(self.__enginesModelChanged)

    def __enginesModelChanged(self):
        """Settings loaded or data updated
        """
        # Remove deleted engines from __disabledByCat
        for engine in list(self.__disabledByCat.keys()):
            if engine not in self._enginesModel:
                del self.__disabledByCat[engine]
                self._widgets['engines'].setKeyEnabled(engine)

        # Add new categories
        for catKey in self._enginesModel.categories():
            if catKey not in self._categoriesModel:

                name = ""
                if catKey in self._searchModel.categories.types:
                    # Default pre-defined categories are translatable
                    name = self._searchModel.categories.types[catKey][0]
                    self._categoriesModel.addCategory(catKey, name)
                else:
                    name = catKey.capitalize()
                    log.debug(f"Found non default category `{name}`", self)
                    self._instanceCategoriesModel.addCategory(catKey, name)

        # Remove old categories
        for catKey in self._categoriesModel.copy():
            if catKey not in self._enginesModel.categories():
                self._categoriesModel.removeCategory(catKey)

                # Release potentialy checked engines
                self.__processCategoriesStateChange(
                    [engine.name for engine in
                     self._enginesModel.getByCategory(catKey)],
                    catKey,
                    False
                )

        # Remove old instance specific categories
        for catKey in self._instanceCategoriesModel.copy():
            if (catKey not in self._enginesModel.categories() and
                catKey not in self._categoriesModel):
                self._instanceCategoriesModel.removeCategory(catKey)

                # Release potentialy checked engines
                self.__processCategoriesStateChange(
                    [engine.name for engine in
                     self._enginesModel.getByCategory(catKey)],
                    catKey,
                    False
                )

        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __userCategoryRemoved(self, catKey):
        for engineKey, catList in self.__disabledByCat.copy().items():
            if catKey in catList:
                self.__uncheckEngineByCat(catKey, engineKey)

        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __userCategoriesChanged(self, catKey):
        """ When the user edited a existing user-category this should
        check freshly added engines to this category and uncheck engines
        that have been removed from this category.
        """
        if self._userCategoriesModel[catKey].isChecked():
            engines = self._userCategoriesModel[catKey].engines
            # Uncheck removed engines
            for engineKey, categories in self.__disabledByCat.copy().items():
                if catKey in categories:
                    if engineKey not in engines:
                        self.__uncheckEngineByCat(catKey, engineKey)

            # Check newly added engines
            for engineKey in engines:
                if engineKey not in self.__disabledByCat:
                    self.__checkEngineByCat(catKey, engineKey)

            self.__finalizeCategoriesStateChange()

    def __checkEngineByCat(self, catKey, engineKey):
        """ This method handles checking of a engine by a category.

        @param catKey: Category key
        @type catKey: str

        @param engineKey: Engine key
        @type engineKey: str
        """
        if engineKey not in self._searchModel.engines:
            # User did not check this engine so we are going to.
            self._searchModel.engines.append(
                engineKey
            )
        elif(engineKey not in self.__userCheckedBackup and
             not self._widgets['engines'].keyDisabled(engineKey)):
            # User did check this engine, so we backup that.
            self.__userCheckedBackup.append(engineKey)

        if not self._widgets['engines'].keyDisabled(engineKey):
            # Disable the engine from being toggled by the user.
            self._widgets['engines'].setKeyDisabled(engineKey)

        if engineKey not in self.__disabledByCat:
            self.__disabledByCat.update({engineKey: []})

        # Backup that this category is blocking this engine from
        # being toggled by the user.
        self.__disabledByCat[engineKey].append(catKey)

    def __uncheckEngineByCat(self, catKey, engineKey):
        """ This method handles the unchecking of a engine by a category.

        @param catKey: Category key
        @type catKey: str

        @param engineKey: Engine key
        @type engineKey: str
        """
        if engineKey in self.__disabledByCat:
            if catKey in self.__disabledByCat[engineKey]:
                # This category no longer blocks this engine from
                # being edited by the user.
                self.__disabledByCat[engineKey].remove(catKey)

            if not self.__disabledByCat[engineKey]:
                # No category left that blocks this engine from
                # user-toggleing.
                self._widgets['engines'].setKeyEnabled(engineKey)

                self.__disabledByCat.pop(engineKey)

                if engineKey not in self.__userCheckedBackup:
                    # User didn't check this engine, so we can
                    # uncheck it.
                    self._searchModel.engines.remove(
                        engineKey
                    )
                else:
                    # User did check this engine before checking
                    # this category so we won't uncheck it.
                    self.__userCheckedBackup.remove(engineKey)

    def __userCategoriesStateChanged(self, catKey, state):
        """ The user checked or unchecked a user-category.

        @param catKey: Category key
        @type catKey: str

        @param state:Category enabled or disabled (checked or unchecked)
        @type state: bool
        """
        self.__processCategoriesStateChange(
            self._userCategoriesModel[catKey].engines,
            catKey,
            state
        )
        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __categoriesStateChanged(self, catKey, state):
        """ The user checked or unchecked a default-category.

        @param catKey: Category key
        @type catKey: str

        @param state: Category enabled or disabled (checked or unchecked)
        @type state: bool
        """
        self.__processCategoriesStateChange(
            [engine.name for engine in
             self._enginesModel.getByCategory(catKey)],
            catKey,
            state
        )
        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __processCategoriesStateChange(self, engines, catKey, state):
        """ The user checked or unchecked a category, depending on the
        `state` variable.

        When a category gets checked all the engines in that category
        will be checked and disabled so that the user can't toggle the
        engine.

        On uncheck of a category all engines in that category should be
        re-enabled. And those engines should be unchecked if they weren't
        checked by the user before checking this category.

        @param engines: A list with engineKeys (str) that are part of the
                        category (catKey).
        @type engines: list

        @param catKey: Category key
        @type catKey: str

        @param state: Category enabled or disabled (checked or unchecked)
        @type state: bool
        """
        if state:
            # Category checked.
            for engine in engines:
                self.__checkEngineByCat(catKey, engine)
        else:
            # Category unchecked.
            for engine in engines:
                self.__uncheckEngineByCat(catKey, engine)

    def __finalizeCategoriesStateChange(self):
        # Re-generate the engines label
        self._widgets['engines'].reGenerate()

        # Update the instances filter.
        self._widgets['engines'].updateFilter()

    def saveSettings(self):
        data = {}

        # Store widgets visible state.
        for key, widget in self._widgets.items():
            data.update({
                f"{key}Visible": not widget.isHidden()
            })

        # Store category states and CheckboxOptionsButton states (label
        # expanded or collapsed)
        data.update({
            'userCatModel': self._userCategoriesModel.data(),
            'defaultCatModel': self._categoriesModel.data(),
            'categoriesButton': self._widgets['categories'].saveSettings(),
            'enginesButton': self._widgets['engines'].saveSettings(),
            'language': self._widgets['lang'].saveSettings()
        })

        return data

    def loadSettings(self, data):
        # Set widgets visible or hidden depending on their state.
        for key, widget in self._widgets.items():
            if data.get(f"{key}Visible", True):
                widget.show()
            else:
                widget.hide()

        # Load category states
        self._userCategoriesModel.setData(data.get('userCatModel', {}))
        self._categoriesModel.setData(data.get('defaultCatModel', {}))

        # Load CheckboxOptionsButton states (categories and engines label
        # states, expanded or collapsed.)
        self._widgets['categories'].loadSettings(
            data.get('categoriesButton', {})
        )
        self._widgets['engines'].loadSettings(data.get('enginesButton', {}))

        # Load search language.
        self._widgets['lang'].loadSettings(data.get('language', {}))

    def __checkBoxStateChanged(self, key, state):
        if state:
            self._widgets[key].show()
        else:
            self._widgets[key].hide()

    """ QFrame re-implementations
    """
    def contextMenuEvent(self, event):
        menu = QMenu(self)
        menu.addSection(_("Show / Hide"))

        for key, widget in self._widgets.items():
            action = QWidgetAction(menu)
            checkbox = QCheckBox(key, menu)
            checkbox.setTristate(False)
            checkbox.setChecked(not widget.isHidden())
            action.setDefaultWidget(checkbox)

            checkbox.stateChanged.connect(
                lambda state, key=key:
                    self.__checkBoxStateChanged(key, state)
            )

            menu.addAction(action)

        menu.exec_(self.mapToGlobal(event.pos()))


""" Find text in the search results

    Shortcuts:
     - 'Return' find text.
     - 'Shift+Return' find previous text.
     
"""
class ResultSearcher(QWidget):
    closeRequest = pyqtSignal()

    def __init__(self, resultsContainer, parent):
        QWidget.__init__(self, parent)

        self.__resultsContainer = resultsContainer

        layout = QHBoxLayout(self)

        self.__inputEdit = QLineEdit(self)
        self.__inputEdit.setPlaceholderText(_("Find .."))
        self.__inputEdit.installEventFilter(self)

        self.__caseCheckbox = QCheckBox(_("Case sensitive"), self)
        self.__wholeCheckbox = QCheckBox(_("Whole words"), self)
        nextButton = Button("►", self)
        prevButton = Button("◄", self)
        closeButton = Button("X", self)

        layout.addWidget(self.__inputEdit)
        layout.addWidget(prevButton)
        layout.addWidget(nextButton)
        layout.addWidget(self.__caseCheckbox)
        layout.addWidget(self.__wholeCheckbox)
        layout.addWidget(closeButton)

        closeButton.clicked.connect(self.closeRequest)
        nextButton.clicked.connect(self.__search)
        prevButton.clicked.connect(self.__searchPrev)

    def eventFilter(self, source, event):
        if event.type() == QEvent.KeyPress and source is self.__inputEdit:
            if event.key() == Qt.Key_Return and event.modifiers() == Qt.ShiftModifier:
                self.__search(reverse=True)
            elif event.key() == Qt.Key_Return:
                self.__search()
            elif event.key() == Qt.Key_Escape:
                self.closeRequest.emit()
        return QLineEdit.eventFilter(self, source, event)

    def focusInput(self):
        self.__inputEdit.setFocus()

    def __searchPrev(self):
        self.__search(reverse=True)

    def __search(self, reverse=False):
        text = self.__inputEdit.text()
        flags = QTextDocument.FindFlag(0)

        if reverse:
            flags |= QTextDocument.FindBackward

        if self.__caseCheckbox.isChecked():
            flags |= QTextDocument.FindCaseSensitively

        if self.__wholeCheckbox.isChecked():
            flags |= QTextDocument.FindWholeWords

        self.__resultsContainer.find(text, options=flags)


class SearchContainer(QWidget):
    def __init__(self, searchModel, instancesModel,
                 instanceSelecter, enginesModel, guard, parent=None):
        """
        @type searchModel: models.search.SearchModel
        @type instancesModel: models.instances.InstancesModelFilter
        @type instanceSelecter: models.instances.InstanceSelecterModel
        @type enginesModel: models.instances.EnginesModel
        @type guard: core.guard.Guard
        """

        QWidget.__init__(self, parent=parent)
        layout = QVBoxLayout(self)

        self._model = searchModel
        self._instancesModel = instancesModel
        self._instanceSelecter = instanceSelecter
        self._guard = guard

        # Maximum other instances to try on fail.
        self._maxSearchFailCount = 10

        # Set `_useFallback` to True to try another instance when the
        # search failed somehow or set to False to try same instance or
        # for pagination which also should use the same instance.
        self._useFallback = True

        # `_fallbackActive` should be False when a fresh list of fallback
        # instances should be picked on failed search and should be True
        # when search(es) fail and `_fallbackInstancesQueue` is beeing
        # used.
        self._fallbackActive = False

        # Used to store errors when fallback is active so they can be listed
        # to the user when even the fallback fails.
        self._fallbackErrors = []

        # Every first request that has `_useFallback` set to True will
        # use this list as a resource for next instance(s) to try until
        # it is out of instance url's. Also on the first request this
        # list will be cleared and filled again with `_maxSearchFailCount`
        # of random instances.
        self._fallbackInstancesQueue = []

        # Set to True to break out of the fallback loop.
        # This is used for the Stop action.
        self._breakFallback = False

        searchLayout = QHBoxLayout()
        layout.addLayout(searchLayout)

        # -- Start search bar
        self.queryEdit = QLineEdit(self)
        self.queryEdit.setPlaceholderText(_("Search for .."))
        searchLayout.addWidget(self.queryEdit)

        self.searchButton = Button(_("Searx"), self)
        self.searchButton.setToolTip(_("Preform search."))
        searchLayout.addWidget(self.searchButton)

        self.reloadButton = Button("♻", self)
        self.reloadButton.setToolTip(_("Reload"))
        searchLayout.addWidget(self.reloadButton)

        self.randomButton = Button("⤳", self)
        self.randomButton.setToolTip(_(
            "Search with random instance.\n"
            "(Obsolete when 'Random Every is checked')"
        ))
        searchLayout.addWidget(self.randomButton)

        rightLayout = QVBoxLayout()
        rightLayout.setSpacing(0)
        searchLayout.addLayout(rightLayout)

        self._fallbackCheck = QCheckBox(_("Fallback"), self)
        self._fallbackCheck.setToolTip(_("Try random other instance on fail."))
        rightLayout.addWidget(self._fallbackCheck)

        self._randomCheckEvery = QCheckBox(_("Random every"), self)
        self._randomCheckEvery.setToolTip(_("Pick a random instance for "
                                          "every request."))
        rightLayout.addWidget(self._randomCheckEvery)
        # -- End search bar

        # -- Start splitter
        self.splitter = QSplitter(self)
        self.splitter.setOrientation(Qt.Vertical)
        layout.addWidget(self.splitter)

        # ---- Start search options toolbar
        self._optionsContainer = SearchOptionsContainer(
            searchModel, instancesModel, enginesModel, self.splitter
        )
        # ---- End search options toolbar

        # --- Start search results container
        searchBottomWidget = QWidget(self.splitter)
        self.searchBottomWidgetLayout = QVBoxLayout(searchBottomWidget)

        self.resultsContainer = QTextBrowser(self)
        self.resultsContainer.setOpenLinks(False)
        self.resultsContainer.setOpenExternalLinks(False)
        self.resultsContainer.setLineWrapMode(1)
        self.searchBottomWidgetLayout.addWidget(self.resultsContainer)

        self.navBar = SearchNavigation(self)
        self.navBar.setEnabled(False)
        self.searchBottomWidgetLayout.addWidget(self.navBar)
        # --- End search results container
        # -- End splitter

        # Find text
        self.__findShortcut = QShortcut('Ctrl+F', self);
        self.__findShortcut.activated.connect(self.__openFind);
        self.__resultsSearcher = None

        self.queryEdit.textChanged.connect(self.__queryChanged)
        self._model.statusChanged.connect(self.__searchStatusChanged)
        self._model.optionsChanged.connect(self.__searchOptionsChanged)
        self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
        self._randomCheckEvery.stateChanged.connect(
                                self.__randomEveryRequestChanged)
        self._instanceSelecter.instanceChanged.connect(
                                self.__instanceChanged)

        self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
        self.searchButton.clicked.connect(self.__searchButtonClicked)
        self.reloadButton.clicked.connect(self.__reloadButtonClicked)
        self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
        self.navBar.requestPage.connect(self.__navBarRequest)
        self.resultsContainer.anchorClicked.connect(self.__handleAnchorClicked)

        self.__queryChanged("")

    def isBusy(self):
        return self._model.isBusy

    def cancelAll(self):
        self._breakFallback = True

    def reset(self):
        self._model.reset()
        self.resultsContainer.setHtml("")
        self.resultsContainer.clearHistory()

    def __openFind(self):
        if self.__resultsSearcher is None:
            self.__resultsSearcher = ResultSearcher(self.resultsContainer, self)
            self.searchBottomWidgetLayout.insertWidget(1, self.__resultsSearcher)
            self.__resultsSearcher.closeRequest.connect(self.__closeFind)

        self.__resultsSearcher.focusInput()

    def __closeFind(self):
        self.searchBottomWidgetLayout.removeWidget(self.__resultsSearcher)
        self.__resultsSearcher.deleteLater()
        self.__resultsSearcher = None

    def __handleAnchorClicked(self, url):
        scheme = url.scheme()

        # Internal from sugestions/corrections
        if scheme == 'search':
            self.queryEdit.setText(url.path())
            self._newSearch(self._instanceSelecter.currentUrl)
            return

        # Custom command
        if (AnchorCMD.handle(scheme, url.toString())):
            return  # Handled by custom command

        # Let QDesktopServices handle known schemes
        if scheme in ['http', 'https', 'magnet', 'ftp']:
            QDesktopServices.openUrl(url)

        # Unknown schemes are not doing anything .. :-)

    def __searchButtonClicked(self, checked=0):
        # Set to use fallback
        self._useFallback = self._model.useFallback

        if (self._model.randomEvery or
                (self._useFallback and
                    not self._instanceSelecter.currentUrl)):
            self._instanceSelecter.randomInstance()

        self._resetPagination()

        self._newSearch(self._instanceSelecter.currentUrl)

    def __stopButtonClicked(self):
        self._breakFallback = True
        self.searchButton.setEnabled(False)

    def __reloadButtonClicked(self):
        self._useFallback = False
        self._newSearch(self._instanceSelecter.currentUrl)

    def __randomSearchButtonClicked(self):
        self._useFallback = self._model.useFallback
        self._instanceSelecter.randomInstance()
        self._newSearch(self._instanceSelecter.currentUrl)

    def __navBarRequest(self, pageNo):
        self._useFallback = False
        self._model.pageno = pageNo
        self._newSearch(self._instanceSelecter.currentUrl)

    def _resetPagination(self):
        self.navBar.reset()
        self._model.pageno = 1

    def __instanceChanged(self):
        self._resetPagination()

    def __searchOptionsChanged(self):
        """ From the model (on load settings)
        """
        self._randomCheckEvery.stateChanged.disconnect(
                                self.__randomEveryRequestChanged)
        self._fallbackCheck.stateChanged.disconnect(
                                self.__useFallbackChanged)

        self._randomCheckEvery.setChecked(self._model.randomEvery)
        self._fallbackCheck.setChecked(self._model.useFallback)

        self.__handleRandomButtonState()

        self._randomCheckEvery.stateChanged.connect(
                                self.__randomEveryRequestChanged)
        self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)

    def __handleRandomButtonState(self):
        """ Hides or shows the 'Random search button'.
        We don't need the button when the model it's randomEvery is True.
        """
        if self._model.randomEvery:
            self.randomButton.hide()
        else:
            self.randomButton.show()

    def __randomEveryRequestChanged(self, state):
        """ From the checkbox
        """
        self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
        self._model.randomEvery = bool(state)
        self.__handleRandomButtonState()
        self._model.optionsChanged.connect(self.__searchOptionsChanged)

    def __useFallbackChanged(self, state):
        """ From the checkbox
        """
        self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
        self._model.useFallback = bool(state)
        self._model.optionsChanged.connect(self.__searchOptionsChanged)

    def __queryChanged(self, q):
        if self._model.status() == SearchStatus.Busy:
            return

        if q:
            self.searchButton.setEnabled(True)
            self.reloadButton.setEnabled(True)
            self.randomButton.setEnabled(True)
        else:
            self.searchButton.setEnabled(False)
            self.reloadButton.setEnabled(False)
            self.randomButton.setEnabled(False)

    def _setOptionsState(self, state=True):
        if self._useFallback:
            if state:
                # Search stopped
                self.searchButton.setText(_("Search"))
                self.searchButton.clicked.disconnect(
                    self.__stopButtonClicked
                )
                self.searchButton.clicked.connect(self.__searchButtonClicked)
                self.searchButton.setEnabled(True)
            else:
                # Searching
                self.searchButton.setText(_("Stop"))
                self.searchButton.clicked.disconnect(
                    self.__searchButtonClicked
                )
                self.searchButton.clicked.connect(self.__stopButtonClicked)
        else:
            self.searchButton.setEnabled(state)

        self.reloadButton.setEnabled(state)
        self.randomButton.setEnabled(state)
        self._randomCheckEvery.setEnabled(state)
        self._fallbackCheck.setEnabled(state)
        self.queryEdit.setEnabled(state)
        self._optionsContainer.setEnabled(state)

    def __searchStatusChanged(self, status):
        if status == SearchStatus.Busy:
            self._setOptionsState(False)

        elif status == SearchStatus.Done:
            self._setOptionsState()

    @property
    def query(self): return self.queryEdit.text()

    def _newSearch(self, url, query=''):
        self.resultsContainer.clear()
        self._search(url, query)

    def _search(self, url, query=''):
        if self._model.isBusy:
            self.resultsContainer.setHtml(_("Search model is busy, " \
                                            "when this occurs that " \
                                            "is a bug, please report."))
            return

        if not query:
            query = self.query

        if not query:
            self.resultsContainer.setHtml(_("Please enter a search query."))
            return

        if not url:
            self.resultsContainer.setHtml(_("Please select a instance first."))
            return

        self.navBar.setEnabled(False)

        self._model.url = url
        self._model.query = query

        self._model.statusChanged.connect(self._searchStatusChanged)
        self._model.search()

    def _searchStatusChanged(self, status):
        if status != SearchStatus.Done:
            return

        self._searchFinished()

    def _searchFinished(self):
        self._model.statusChanged.disconnect(self._searchStatusChanged)

        response = self._model.response
  
        # Guard
        if self._guard.isEnabled():
            currentUrl = self._instanceSelecter.currentUrl

            # Report the search result to Guard.
            self._guard.reportSearchResult(currentUrl, response)

        if response.error != ErrorType.Success:  # Failed
            self._searchFailed(response)
            return

        self._fallbackActive = False

        self.resultsContainer.setHtml(
            ResultsHtml.create(response.json(), Themes.htmlCssResults)
        )
        self.navBar.setEnabled(True)
        self.navBar.setNextEnabled(True)

    def _searchFailed(self, response):
        currentUrl = self._instanceSelecter.currentUrl  # backup

        # Don't go further on proxy errors.
        #  - Guard should not handle proxy errors.
        #  - Fallback should be disabled for proxy errors.
        if response.error == ErrorType.ProxyError:
            self._breakFallback = False
            self.resultsContainer.setHtml(
                FailedResponsesHtml.create([response], Themes.htmlCssFail)
            )
            return

        if self._guard.isEnabled():
            # See if the Guard has any consequence for this instance.
            consequence = self._guard.getConsequence(currentUrl)
            if consequence:
                # Apply the consequence.
                if consequence.type == ConsequenceType.Blacklist:
                    # Blacklist the instance.
                    self._instancesModel.putInstanceOnBlacklist(
                        currentUrl,
                        reason=response.errorMessage
                    )
                else:
                    # Put the instance on a timeout.
                    self._instancesModel.putInstanceOnTimeout(
                        currentUrl,
                        duration=consequence.duration,
                        reason=response.errorMessage
                    )
                self._instancesModel.apply()  # Apply the changed filter.

        if self._useFallback:  # Re-try another instance
            if self._breakFallback:  # Stop button pressed
                self._breakFallback = False
                self._fallbackActive = False
                self._fallbackErrors.append(response)
                self.resultsContainer.setHtml(
                    FailedResponsesHtml.create(
                        self._fallbackErrors,
                        f"{_('Stop button pressed!')} ",
                        Themes.htmlCssFail)
                )
                self._fallbackErrors.clear()
                return

            if not self._fallbackActive:
                # Get new list with instances to try same request.
                self._fallbackActive = True
                self._fallbackErrors.clear()
                self._fallbackInstancesQueue.clear()
                self._fallbackInstancesQueue = (
                        self._instanceSelecter.getRandomInstances(
                                    amount=self._maxSearchFailCount))

            if not self._fallbackInstancesQueue:
                self.resultsContainer.setHtml(
                    FailedResponsesHtml.create(
                        self._fallbackErrors,
                        f"{_('Max fail count reached!')} " \
                        f"({self._maxSearchFailCount})",
                        Themes.htmlCssFail)
                )
                self._fallbackActive = False
                self._fallbackErrors.clear()
                return

            # Append current error to error list
            self._fallbackErrors.append(response)

            # Set next instance url to try.
            self._instanceSelecter.currentUrl = (
                    self._fallbackInstancesQueue.pop(0))

            self._search(self._instanceSelecter.currentUrl)
            return

        if self._model.pageno > 1:
            self.navBar.setEnabled(True)
            self.navBar.setNextEnabled(False)

        self.resultsContainer.setHtml(
            FailedResponsesHtml.create([response],
                                       _("Search request failed."),
                                       Themes.htmlCssFail)
        )

    def saveSettings(self):
        return {
            'searchOptions': self._optionsContainer.saveSettings(),
            'splitterState': self.splitter.saveState()
        }

    def loadSettings(self, data):
        self.queryEdit.setText("")

        self._optionsContainer.loadSettings(
            data.get('searchOptions', {})
        )

        self.splitter.restoreState(
            data.get('splitterState', QByteArray())
        )
